大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 25 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。本章節講述的是如何透過 framebuffer 使 WebGL 預先計算資料到 texture,並透過這些預計算的資料製作鏡面、陰影效果,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
在 Day 24 渲染好深度並繪製到畫面上,可以看到中間一顆球的輪廓,並且在其頂部的地方顏色深度更深,表示更接近深度投影的投影面,接下來讓這個拍攝深度的目標移動到 framebuffer/texture 去,並且在渲染給使用者時使用
現在開始除了鏡面的 framebuffer 渲染之外又要多了光源投影,為了讓渲染到不同 framebuffer 之程式能夠在程式碼中比較好分辨,筆者建立一個 {}
區域來表示這個區域在做光源投影:
function render(app) {
// ...
{ // lightProjection
gl.useProgram(depthProgramInfo.program);
twgl.bindFramebufferInfo(gl, framebuffers.lightProjection);
gl.clear(gl.DEPTH_BUFFER_BIT);
renderBall(app, lightProjectionViewMatrix, depthProgramInfo);
renderGround(app, lightProjectionViewMatrix, mirrorViewMatrix, depthProgramInfo);
}
// ...
}
把這個區域放置在鏡面 framebuffer 渲染前,畢竟在鏡面世界可以看到陰影。因為渲染到鏡面世界時與正式渲染都使用主要的 programInfo
,把 gl.useProgram()
移動下來與設定全域 uniform 到此 programInfo
的 twgl.setUniforms()
放在一起,同時也把鏡面世界的渲染用 {}
包起來:
function render(app) {
const {
gl,
- framebuffers,
+ framebuffers, textures,
programInfo, depthProgramInfo,
state,
} = app;
- gl.useProgram(programInfo.program);
-
const lightProjectionViewMatrix = matrix4.multiply( /* ... */)
// ...
{ // lightProjection
// ...
}
+ gl.useProgram(programInfo.program);
twgl.setUniforms(programInfo, {
u_worldViewerPosition: cameraMatrix.slice(12, 15),
u_lightDirection: lightDirection,
u_ambient: [0.4, 0.4, 0.4],
});
- twgl.bindFramebufferInfo(gl, framebuffers.mirror);
- gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+ { // mirror
+ twgl.bindFramebufferInfo(gl, framebuffers.mirror);
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
- renderBall(app, mirrorViewMatrix, programInfo);
+ renderBall(app, mirrorViewMatrix, programInfo);
+ }
// ...
}
最後是讓正式『畫』的程式回復使用 viewMatrix
, programInfo
:
function render(app) {
// ...
{ // mirror
// ...
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.canvas.width = gl.canvas.clientWidth;
gl.canvas.height = gl.canvas.clientHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
- gl.useProgram(depthProgramInfo.program);
-
- renderBall(app, lightProjectionViewMatrix, depthProgramInfo);
- renderGround(app, lightProjectionViewMatrix, mirrorViewMatrix, depthProgramInfo);
+ renderBall(app, viewMatrix, programInfo);
+ renderGround(app, viewMatrix, mirrorViewMatrix, programInfo);
}
這麼一來深度資訊就會存在 textures.lightProjection
中,接下來請參考這張圖:
經過光源投影之後,B 點上的深度來自 A 點,如果從 C 進行光源投影同樣會到達 B 點的位置,但是深度將會比較深,我們可以利用這一點來檢查是否在陰影下,把 C 點投影到 B 點的原理其實跟 Day 23 鏡面計算 texture 位置一樣,將在 fragment shader 中得到的表面位置進行 framebuffer 的 view matrix 轉換,也就是 lightProjectionViewMatrix
把光源投影的 view 矩陣用名為 u_lightProjectionMatrix
的 uniform 傳入,並且在 vertex shader 中 transform 成 v_lightProjection
投影後的位置:
uniform mat4 u_mirrorMatrix;
+uniform mat4 u_lightProjectionMatrix;
// ...
varying float v_depth;
+varying vec4 v_lightProjection;
void main() {
v_depth = gl_Position.z / gl_Position.w * 0.5 + 0.5;
+ v_lightProjection = u_lightProjectionMatrix * worldPosition;
}
在 fragment shader 方面,接收 u_lightProjectionMatrix
以及 v_lightProjection
,並且跟 v_mirrorTexcoord
一樣要除以 .w
使之與 clip space 中的位置相同,接著需要兩個深度:
v_lightProjection.z / v_lightProjection.w
計算而來的 lightToSurfaceDepth
: 表示該點(可能為 A 或是 C 點)投影下去的深度u_lightProjectionMap
查詢到的值:光源投影時該點的深度,也就是 B 點上的值// ...
uniform sampler2D u_lightProjectionMap;
varying vec4 v_lightProjection;
void main() {
// ...
vec2 lightProjectionCoord =
v_lightProjection.xy / v_lightProjection.w * 0.5 + 0.5;
float lightToSurfaceDepth =
v_lightProjection.z / v_lightProjection.w * 0.5 + 0.5;
float lightProjectedDepth = texture2D(
u_lightProjectionMap,
lightProjectionCoord
).r;
}
除了 lightProjectionCoord
要 * 0.5 + 0.5
以符合 texture 上的座標範圍外,v_lightProjection.z / v_lightProjection.w
在 clip space 為 -1 ~ +1,也要傳換成 0 ~ +1,以符合深度 texture 『顏色』的 channel 值域。資料準備就緒,進行深度比較:
float occulusion = lightToSurfaceDepth > lightProjectedDepth ? 0.5 : 0.0;
diffuseBrightness *= 1.0 - occulusion;
specularBrightness *= 1.0 - occulusion * 2.0;
筆者使用 occulusion
表示『有多少成的光源被遮住』,並設定成在陰影下時減少 50% 的散射光亮度以及全部反射光,結果長這樣:
真的該有陰影的地方是有陰影了:
不過顯然陰影區域太大,而且球體上光照的區域也有一點一點的陰影,為什麼會這樣呢?儘管像是上方示意圖中的 A 點,光源投影下來的深度與後來重算的深度可能因為 GPU 計算過程中浮點數的微小差異而導致 lightToSurfaceDepth > lightProjectedDepth
成立,為了避免這個問體我們讓 lightToSurfaceDepth
必須比 lightProjectedDepth
還要大出一定的數值才判定為有陰影,筆者讓這個值為 0.01
:
void main() {
// ..
- float occulusion = lightToSurfaceDepth > lightProjectedDepth ? 0.5 : 0.0;
+ float occulusion = lightToSurfaceDepth > 0.01 + lightProjectedDepth ? 0.5 : 0.0;
}
陰影功能就完成囉:
完整的程式碼可以在這邊找到:
好了,花了這麼多篇介紹光線相關的效果,從散射光、反射光到鏡面與陰影,這些效果加在一起可以製作出頗生動的畫面,不覺得上面的畫面蠻漂亮的嗎?在此同時本系列技術文章也將進入尾聲,下個章節將製作一個完整的場景作為完結作品:帆船與海